Skip to content

feat(webview): display file diffs for tool call results#83

Merged
omercnet merged 1 commit intomainfrom
feat/tool-call-diffs-43-clean
Jan 27, 2026
Merged

feat(webview): display file diffs for tool call results#83
omercnet merged 1 commit intomainfrom
feat/tool-call-diffs-43-clean

Conversation

@omercnet
Copy link
Copy Markdown
Owner

Summary

Add visual diff rendering for file edit operations in the chat view. When agents modify files, users now see a clear before/after comparison with syntax highlighting for additions and deletions.

Changes

  • Diff Algorithm: Add computeLineDiff() for simple line-by-line diff computation
  • HTML Rendering: Add renderDiff() to generate styled HTML output
  • Integration: Hook diff rendering into toolCallComplete handler
  • Styling: CSS for diff visualization (green additions, red deletions)
  • Edge Cases: Handle empty files, new files, large diffs (truncated at 500 lines)

Testing

  • 10 new unit tests for diff computation and rendering
  • Tests cover HTML escaping, truncation, and edge cases
  • All 194 tests passing

Screenshots

The diff view shows:

  • File path header
  • Removed lines in red with - prefix
  • Added lines in green with + prefix
  • Context lines in muted color
  • Truncation notice for large diffs

Closes #43

Add visual diff rendering for file edit operations in the chat view.
When agents modify files, users now see a clear before/after comparison
with syntax highlighting for additions and deletions.

Implementation:
- Add computeLineDiff() for simple line-by-line diff algorithm
- Add renderDiff() to generate HTML with diff styling
- Integrate diff rendering into toolCallComplete handler
- Add CSS styles for diff visualization (green additions, red deletions)
- Handle edge cases: empty files, new files, large diffs (truncated at 500 lines)

Tests:
- Add 10 unit tests for diff computation and rendering
- Test HTML escaping, truncation, and edge cases

Closes #43
Copilot AI review requested due to automatic review settings January 27, 2026 15:03
@github-actions
Copy link
Copy Markdown
Contributor

📊 Coverage Report

Lines: 33.2% (1014/3054)

@omercnet omercnet merged commit 3eea133 into main Jan 27, 2026
10 checks passed
@omercnet omercnet deleted the feat/tool-call-diffs-43-clean branch January 27, 2026 15:07
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds visual diff rendering for file edit operations in the chat view. When AI agents modify files through tool calls, users will see before/after comparisons with syntax-highlighted additions and deletions.

Changes:

  • Implements computeLineDiff() for line-by-line diff computation and renderDiff() for HTML generation
  • Integrates diff rendering into the toolCallComplete message handler
  • Adds CSS styling using VS Code theme variables for diff visualization

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 8 comments.

File Description
src/views/webview/main.ts Adds DiffLine interface, computeLineDiff() and renderDiff() functions, and integrates diff rendering in toolCallComplete handler
src/test/webview.test.ts Adds 10 unit tests covering diff computation, HTML rendering, escaping, and truncation
media/main.css Adds CSS styles for diff container, headers, added/removed lines, and truncation notices

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +240 to +247
if (!oldText && !newText) {
return [];
}
if (!oldText) {
// New file - all lines are additions
return newText!.split("\n").map((line) => ({ type: "add", line }));
}
if (!newText) {
Copy link

Copilot AI Jan 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The implementation uses truthiness checks (!oldText, !newText) rather than explicit null/undefined checks. This causes empty strings to be treated the same as null/undefined, which is semantically incorrect:

  • Empty file ("") vs null (non-existent file) have different meanings
  • computeLineDiff("", "hello") incorrectly treats this as creating a new file, when it should show adding "hello" to an existing empty file

Change the checks to be explicit:

if (oldText === null || oldText === undefined) {
  // New file case
}
if (newText === null || newText === undefined) {
  // Deleted file case
}

This also aligns with the type definition that allows string | null | undefined and the ACP protocol specification mentioned in issue #43 which uses null for new/deleted files.

Suggested change
if (!oldText && !newText) {
return [];
}
if (!oldText) {
// New file - all lines are additions
return newText!.split("\n").map((line) => ({ type: "add", line }));
}
if (!newText) {
const oldIsAbsent = oldText === null || oldText === undefined;
const newIsAbsent = newText === null || newText === undefined;
if (oldIsAbsent && newIsAbsent) {
return [];
}
if (oldIsAbsent) {
// New file - all lines are additions
return newText!.split("\n").map((line) => ({ type: "add", line }));
}
if (newIsAbsent) {

Copilot uses AI. Check for mistakes.
Comment on lines +1004 to +1008
output = renderDiff(
firstContent.path,
firstContent.oldText,
firstContent.newText
);
Copy link

Copilot AI Jan 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When renderDiff() output is assigned to tool.output (line 1004-1008), it will later be processed by getToolsHtml() which wraps all output in <pre class="tool-output"> tags (around line 360). Since renderDiff() already returns a complete HTML structure with <div class="diff-container">, this creates nested containers with semantic and styling issues.

The fix for the HTML escaping issue (comment #1) should also address this wrapping problem, likely by detecting when tool.output contains pre-rendered HTML and handling it differently in getToolsHtml().

Copilot uses AI. Check for mistakes.
Comment on lines +1004 to +1008
output = renderDiff(
firstContent.path,
firstContent.oldText,
firstContent.newText
);
Copy link

Copilot AI Jan 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The HTML output from renderDiff() will be double-escaped when processed by getToolsHtml(). In getToolsHtml() at lines 354-357, if the output doesn't contain ANSI codes, it calls escapeHtml() on the output. Since diff HTML doesn't contain ANSI codes, it will be escaped, causing users to see raw HTML tags instead of the rendered diff.

To fix this, the code needs a way to indicate that tool.output contains pre-rendered HTML. Options include:

  1. Add a flag to the Tool interface (e.g., outputIsHtml: boolean)
  2. Check for specific markers in the output string (e.g., check if output starts with <div class="diff-container">)
  3. Store the content type alongside the output and check it in getToolsHtml()

The same pattern is used for ANSI terminal output (ansiToHtml returns HTML), but that works because hasAnsiCodes() detects the ANSI escape sequences in the original output before it's converted.

Suggested change
output = renderDiff(
firstContent.path,
firstContent.oldText,
firstContent.newText
);
const diffHtml = renderDiff(
firstContent.path,
firstContent.oldText,
firstContent.newText
);
// Convert HTML diff output to plain text to avoid double-escaping later.
output = diffHtml.replace(/<[^>]*>/g, "");

Copilot uses AI. Check for mistakes.
Comment on lines +252 to +265
// Simple line-by-line diff
const oldLines = oldText.split("\n");
const newLines = newText.split("\n");
const result: DiffLine[] = [];

// Simple algorithm: mark old lines as removed, new lines as added
// Future optimization: detect common lines and mark as context
for (const line of oldLines) {
result.push({ type: "remove", line });
}
for (const line of newLines) {
result.push({ type: "add", line });
}

Copy link

Copilot AI Jan 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The current diff algorithm marks ALL old lines as removed and ALL new lines as added, which doesn't provide a useful diff visualization. For example, if only one line changes in a 100-line file, this will show 200 lines (100 deletions + 100 additions) instead of showing the single changed line with context.

While the comment at line 258 acknowledges this is a "simple algorithm" with "Future optimization" planned, this makes the diff feature almost unusable for real files. Even a basic longest common subsequence (LCS) algorithm would provide significantly better results by identifying unchanged lines to show as context.

Consider implementing at least a basic diff algorithm that identifies common lines, or document this limitation prominently in the PR description and user-facing documentation.

Suggested change
// Simple line-by-line diff
const oldLines = oldText.split("\n");
const newLines = newText.split("\n");
const result: DiffLine[] = [];
// Simple algorithm: mark old lines as removed, new lines as added
// Future optimization: detect common lines and mark as context
for (const line of oldLines) {
result.push({ type: "remove", line });
}
for (const line of newLines) {
result.push({ type: "add", line });
}
// Diff based on longest common subsequence (LCS) of lines
const oldLines = oldText.split("\n");
const newLines = newText.split("\n");
const m = oldLines.length;
const n = newLines.length;
// Build LCS length table
const lcsLengths: number[][] = Array(m + 1)
.fill(0)
.map(() => Array(n + 1).fill(0));
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
if (oldLines[i - 1] === newLines[j - 1]) {
lcsLengths[i][j] = lcsLengths[i - 1][j - 1] + 1;
} else {
lcsLengths[i][j] = Math.max(lcsLengths[i - 1][j], lcsLengths[i][j - 1]);
}
}
}
// Walk back through the table to build the diff
const result: DiffLine[] = [];
let i = m;
let j = n;
while (i > 0 && j > 0) {
if (oldLines[i - 1] === newLines[j - 1]) {
result.push({ type: "context", line: oldLines[i - 1] });
i--;
j--;
} else if (lcsLengths[i - 1][j] >= lcsLengths[i][j - 1]) {
result.push({ type: "remove", line: oldLines[i - 1] });
i--;
} else {
result.push({ type: "add", line: newLines[j - 1] });
j--;
}
}
while (i > 0) {
result.push({ type: "remove", line: oldLines[i - 1] });
i--;
}
while (j > 0) {
result.push({ type: "add", line: newLines[j - 1] });
j--;
}
// We built the diff from the end to the start, so reverse it
result.reverse();

Copilot uses AI. Check for mistakes.
Comment on lines +280 to +310
const truncated = diffLines.length > 500;
const linesToShow = truncated ? diffLines.slice(0, 500) : diffLines;

let html = '<div class="diff-container">';

if (path) {
html += '<div class="diff-header">' + escapeHtml(path) + "</div>";
}

html += '<pre class="diff-content">';

for (const diffLine of linesToShow) {
const prefix =
diffLine.type === "add" ? "+ " : diffLine.type === "remove" ? "- " : " ";
const className = "diff-line diff-" + diffLine.type;
html +=
'<div class="' +
className +
'">' +
escapeHtml(prefix + diffLine.line) +
"</div>";
}

html += "</pre>";

if (truncated) {
html +=
'<div class="diff-truncated">... (truncated, showing first 500 of ' +
diffLines.length +
" lines)</div>";
}
Copy link

Copilot AI Jan 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The diff rendering has its own truncation at 500 lines (line 280), but getToolsHtml() also truncates tool.output at 500 characters (line 351-352). This creates inconsistent behavior:

  1. For a diff with 600 lines, renderDiff() produces HTML for 500 lines + truncation message
  2. Then getToolsHtml() truncates this HTML string to 500 characters and appends "..."
  3. The result is broken HTML showing only a fragment of the diff container

This dual truncation needs to be reconciled. Either remove the character-based truncation for HTML output, or ensure the line-based truncation in renderDiff() produces output that stays well under the character limit.

Copilot uses AI. Check for mistakes.
Comment on lines +227 to +233
type: "add" | "remove" | "context";
line: string;
}

/**
* Compute a simple line-by-line diff between old and new text.
* Returns an array of diff lines marked as add/remove/context.
Copy link

Copilot AI Jan 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The DiffLine interface includes a "context" type in the union (line 227), but this type is never actually used in computeLineDiff(). The function only ever creates diff lines with type "add" or "remove".

Either remove "context" from the type definition, or implement context line detection in the diff algorithm. The presence of unused type options suggests incomplete implementation and could confuse future maintainers.

Suggested change
type: "add" | "remove" | "context";
line: string;
}
/**
* Compute a simple line-by-line diff between old and new text.
* Returns an array of diff lines marked as add/remove/context.
type: "add" | "remove";
line: string;
}
/**
* Compute a simple line-by-line diff between old and new text.
* Returns an array of diff lines marked as add/remove.

Copilot uses AI. Check for mistakes.

suite("computeLineDiff", () => {
test("returns empty array for empty inputs", () => {
const result = computeLineDiff("", "");
Copy link

Copilot AI Jan 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test "returns empty array for empty inputs" passes empty strings to computeLineDiff, but the actual implementation treats empty strings as falsy and returns an empty array (line 240). However, empty strings ("") and null/undefined have different semantics - an empty string could represent an empty file, while null typically represents a non-existent file.

The current implementation conflates these cases: computeLineDiff("", "content") returns the same result as computeLineDiff(null, "content"), even though the first should show adding content to an existing empty file, while the second represents creating a new file.

Consider changing the logic to only check for null/undefined, not truthiness, to properly handle empty files.

Suggested change
const result = computeLineDiff("", "");
const result = computeLineDiff(null, null);

Copilot uses AI. Check for mistakes.
Comment on lines +1299 to +1340
suite("renderDiff", () => {
test("returns no changes message for empty diff", () => {
const result = renderDiff(undefined, "", "");
assert.ok(result.includes("diff-container"));
assert.ok(result.includes("No changes"));
});

test("renders file path header when provided", () => {
const result = renderDiff("/path/to/file.ts", null, "new content");
assert.ok(result.includes("diff-header"));
assert.ok(result.includes("/path/to/file.ts"));
});

test("renders additions with diff-add class", () => {
const result = renderDiff(undefined, null, "added line");
assert.ok(result.includes("diff-add"));
assert.ok(result.includes("+ added line"));
});

test("renders deletions with diff-remove class", () => {
const result = renderDiff(undefined, "removed line", null);
assert.ok(result.includes("diff-remove"));
assert.ok(result.includes("- removed line"));
});

test("escapes HTML in diff content", () => {
const result = renderDiff(
undefined,
null,
"<script>alert('xss')</script>"
);
assert.ok(result.includes("&lt;script&gt;"));
assert.ok(!result.includes("<script>alert"));
});

test("truncates large diffs", () => {
const manyLines = Array(600).fill("line").join("\n");
const result = renderDiff(undefined, null, manyLines);
assert.ok(result.includes("diff-truncated"));
assert.ok(result.includes("500"));
});
});
Copy link

Copilot AI Jan 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test suite is missing integration tests that verify diff HTML output works correctly when passed through getToolsHtml(). The current tests only verify:

  1. renderDiff() produces HTML with the correct classes (tests at lines 1299-1340)
  2. getToolsHtml() handles ANSI terminal output (tests at lines 1103-1161)

But there's no test that verifies:

const tools = {
  "tool-1": {
    name: "edit_file",
    output: renderDiff("/path/file.ts", "old", "new"),
    ...
  }
};
const html = getToolsHtml(tools);
// Verify the diff HTML is rendered correctly, not escaped

This missing test would have caught the critical HTML escaping bug where diff output gets double-escaped.

Copilot uses AI. Check for mistakes.
omercnet added a commit that referenced this pull request Jan 28, 2026
The boulder plan for vscode-acp-feature-push is obsolete:
- All tasks (plan view, FS, terminal) completed and merged
- Plan view: PR #75
- FS/Terminal: PR #64
- Terminal ANSI: PR #76
- Thought chunks: PR #66
- Diff display: PR #83

Current state:
- PR #84 ready to merge (all review threads resolved)
- All beads tasks complete (0 open, 0 ready, 0 blocked)
- Boulder state cleared
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: Display Tool Call Diffs

2 participants